--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit e5fac4951e897165aef5bd7238b1282c1147ce63
Parents : 82de472
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-05-03T14:05:30-05:00
feat(conversation): various updates to conversations styling, ui/ux and logic
Changes
19 files changed, 787 insertions(+), 219 deletions(-)
Diff
diff --git a/meshchatx/src/backend/lxmf_utils.py b/meshchatx/src/backend/lxmf_utils.py
index 34eeb48c..a59207c2 100644
--- a/meshchatx/src/backend/lxmf_utils.py
+++ b/meshchatx/src/backend/lxmf_utils.py
@@ -183,6 +183,8 @@ def lxmf_sidebar_preview_for_conversation_latest_row(
if isinstance(telemetry, dict):
loc = telemetry.get("location")
if isinstance(loc, dict) and loc:
+ if actor == "You":
+ return "You shared your location"
return f"{actor} shared their location"
ts = fields.get("telemetry_stream")
diff --git a/meshchatx/src/frontend/components/Toast.vue b/meshchatx/src/frontend/components/Toast.vue
index 4d071e79..31bd5f08 100644
--- a/meshchatx/src/frontend/components/Toast.vue
+++ b/meshchatx/src/frontend/components/Toast.vue
@@ -2,7 +2,7 @@
<template>
<div
- class="fixed bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"
+ class="fixed max-sm:bottom-[calc(5.75rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"
>
<TransitionGroup name="toast">
<div
diff --git a/meshchatx/src/frontend/components/contacts/ContactsPage.vue b/meshchatx/src/frontend/components/contacts/ContactsPage.vue
index 76194008..8b07da1f 100644
--- a/meshchatx/src/frontend/components/contacts/ContactsPage.vue
+++ b/meshchatx/src/frontend/components/contacts/ContactsPage.vue
@@ -99,7 +99,6 @@
:icon-background-colour="
contact.remote_icon ? contact.remote_icon.background_colour : ''
"
- :seed="contact.lxmf_address || contact.remote_identity_hash"
icon-class="size-10 sm:size-12"
/>
</div>
diff --git a/meshchatx/src/frontend/components/messages/AddAudioButton.vue b/meshchatx/src/frontend/components/messages/AddAudioButton.vue
index 09a2ea5f..fa3890e9 100644
--- a/meshchatx/src/frontend/components/messages/AddAudioButton.vue
+++ b/meshchatx/src/frontend/components/messages/AddAudioButton.vue
@@ -35,7 +35,7 @@
<div
v-if="isShowingMenu"
v-click-outside="hideMenu"
- class="absolute bottom-0 -ml-11 sm:right-0 sm:ml-0 z-10 mb-10 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-gray-200 dark:ring-zinc-800 focus:outline-hidden"
+ class="absolute bottom-full right-0 z-10 mb-2 max-w-[min(20rem,calc(100vw-1.5rem))] rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-gray-200 dark:ring-zinc-800 focus:outline-hidden"
>
<div class="py-1">
<button
diff --git a/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue b/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
index 30bbf134..a5df386f 100644
--- a/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
@@ -14,7 +14,6 @@
:icon-background-colour="
selectedPeer.lxmf_user_icon ? selectedPeer.lxmf_user_icon.background_colour : ''
"
- :seed="selectedPeer.destination_hash"
icon-class="shrink-0"
:icon-style="messageIconStyle"
/>
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 36fccf6b..1eacb808 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -101,9 +101,6 @@
:icon-name="lxmfContactResolvedIcon(contact).iconName"
:icon-foreground-colour="lxmfContactResolvedIcon(contact).foreground"
:icon-background-colour="lxmfContactResolvedIcon(contact).background"
- :seed="
- lxmfDeliveryDestinationHexFromContact(contact) || contact.remote_identity_hash
- "
icon-class="size-10"
/>
</div>
@@ -527,7 +524,7 @@
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 block w-full min-w-0 pl-3 sm:pl-4 pr-[76px] py-2.5 resize-none shadow-xs transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500 min-h-[44px] max-h-[200px] overflow-y-auto leading-snug"
rows="1"
spellcheck="true"
- :placeholder="$t('messages.send_placeholder')"
+ :placeholder="composeInputPlaceholder"
@keydown.enter.exact.prevent="onEnterPressed"
@keydown.enter.shift.exact.prevent="onShiftEnterPressed"
@paste="onMessagePaste"
@@ -789,11 +786,14 @@
<SendMessageButton
:is-sending-message="false"
:can-send-message="canSendMessage"
+ :can-open-send-menu="Boolean(selectedPeer)"
:delivery-method="newMessageDeliveryMethod"
:compact="compactSendLayout"
:sending-tooltip="sendMessagePathfindingTooltip"
- @send="sendMessage"
+ @send="onComposerSendClick"
@delivery-method-changed="newMessageDeliveryMethod = $event"
+ @send-command-or-request="enableNextSendAsCommandOrRequest"
+ @send-paper-compose="generatePaperMessageFromComposition"
/>
</div>
</div>
@@ -885,52 +885,37 @@
<MaterialDesignIcon icon-name="paperclip-plus" class="w-4 h-4" />
<span class="hidden sm:inline">{{ $t("messages.add_files") }}</span>
</button>
- <button
- type="button"
- class="attachment-action-button"
- :title="$t('messages.paste_from_clipboard')"
- @click="pasteFromClipboard"
- >
- <MaterialDesignIcon icon-name="content-paste" class="w-4 h-4" />
- <span class="hidden sm:inline">Paste</span>
- </button>
<AddImageButton @add-image="onImageSelected" />
- <button
- type="button"
- class="attachment-action-button"
- :title="$t('messages.share_location')"
- @click="shareLocation"
- >
- <MaterialDesignIcon icon-name="map-marker" class="w-4 h-4" />
- <span class="hidden sm:inline">{{ $t("messages.location") }}</span>
- </button>
- <button
- type="button"
- class="attachment-action-button"
- :title="$t('messages.request_location')"
- @click="requestLocation"
- >
- <MaterialDesignIcon icon-name="crosshairs-question" class="w-4 h-4" />
- <span class="hidden sm:inline">{{ $t("messages.request") }}</span>
- </button>
- <button
- type="button"
- class="attachment-action-button"
- :title="$t('messages.generate_paper_message')"
- :disabled="!canSendMessage || isGeneratingPaperMessage"
- @click="generatePaperMessageFromComposition"
- >
- <template v-if="isGeneratingPaperMessage">
- <div
- class="size-4 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
- ></div>
- <span class="hidden sm:inline">Generating...</span>
- </template>
- <template v-else>
- <MaterialDesignIcon icon-name="qrcode-plus" class="w-4 h-4" />
- <span class="hidden sm:inline">LXM</span>
- </template>
- </button>
+ <div v-click-outside="closeLocationActionMenu" class="relative">
+ <button
+ type="button"
+ class="attachment-action-button"
+ :title="$t('messages.location')"
+ @click.stop="toggleLocationActionMenu"
+ >
+ <MaterialDesignIcon icon-name="map-marker" class="w-4 h-4" />
+ <span class="hidden sm:inline">{{ $t("messages.location") }}</span>
+ </button>
+ <div
+ v-if="showLocationActionMenu"
+ class="absolute left-0 bottom-full mb-2 z-50 min-w-[220px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
+ >
+ <button
+ type="button"
+ class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-200 hover:bg-gray-100 dark:hover:bg-zinc-800"
+ @click="selectSendLocation"
+ >
+ {{ $t("messages.share_location") }}
+ </button>
+ <button
+ type="button"
+ class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-200 hover:bg-gray-100 dark:hover:bg-zinc-800"
+ @click="selectRequestLocation"
+ >
+ {{ $t("messages.request_location") }}
+ </button>
+ </div>
+ </div>
<button
v-if="hasTranslator && newMessageText"
type="button"
@@ -952,14 +937,7 @@
</div>
<!-- hidden file input for selecting files -->
- <input
- ref="file-input"
- type="file"
- multiple
- accept="*/*,image/*"
- style="display: none"
- @change="onFileInputChange"
- />
+ <input ref="file-input" type="file" multiple style="display: none" @change="onFileInputChange" />
<!-- Message Context Menu (Teleport to body to avoid overflow clipping) -->
<Teleport to="body">
@@ -1841,6 +1819,8 @@ export default {
newMessageAudio: null,
newMessageTelemetry: null,
newMessageFiles: [],
+ pendingSendAsCommandOrRequest: false,
+ showLocationActionMenu: false,
isTranslatingMessage: false,
autoScrollOnNewMessage: true,
composeAddress: "",
@@ -2068,6 +2048,20 @@ export default {
}
return this.$t("messages.sending_ellipsis");
},
+ composeInputPlaceholder() {
+ void this.newMessageDeliveryMethod;
+ const dm = this.newMessageDeliveryMethod;
+ if (dm === "direct") {
+ return this.$t("messages.compose_hint_direct");
+ }
+ if (dm === "propagated") {
+ return this.$t("messages.compose_hint_propagated");
+ }
+ if (dm === "opportunistic") {
+ return this.$t("messages.compose_hint_opportunistic");
+ }
+ return this.$t("messages.compose_hint_automatic");
+ },
isSyncingPropagationNode() {
return [
"path_requested",
@@ -2143,6 +2137,9 @@ export default {
return suggestions.slice(0, 10);
},
canSendMessage() {
+ if (this.pendingSendAsCommandOrRequest && this.selectedPeer) {
+ return true;
+ }
// can send if message text is present
const messageText = this.newMessageText.trim();
const hasText = messageText != null && messageText !== "";
@@ -2468,6 +2465,19 @@ export default {
window.MeshChatXAndroid.getPlatform() === "android"
);
},
+ androidNativeWavAttachmentAllowed() {
+ const b = window.MeshChatXAndroid;
+ if (!b || typeof b.startNativeWavAttachment !== "function") {
+ return false;
+ }
+ if (typeof b.isNativeWavAttachmentAvailable === "function") {
+ return b.isNativeWavAttachmentAvailable() === true;
+ }
+ if (typeof b.isNativePcmAudioAvailable === "function") {
+ return b.isNativePcmAudioAvailable() === true;
+ }
+ return true;
+ },
setupPeerHeaderResizeObserver() {
this.teardownPeerHeaderResizeObserver();
const root = this.$refs.conversationPeerHeader;
@@ -3486,10 +3496,12 @@ export default {
return;
}
- this.chatItems[chatItemIndex].lxmf_message = {
- ...this.chatItems[chatItemIndex].lxmf_message,
- ...lxmfMessage,
- };
+ const prev = this.chatItems[chatItemIndex].lxmf_message;
+ const merged = { ...prev, ...lxmfMessage };
+ if (!Object.prototype.hasOwnProperty.call(lxmfMessage, "_pendingPathfinding")) {
+ delete merged._pendingPathfinding;
+ }
+ this.chatItems[chatItemIndex].lxmf_message = merged;
},
onLxmfMessageDeleted(hash) {
if (hash) {
@@ -4746,10 +4758,10 @@ export default {
let audioTotalSize = 0;
if (this.newMessageAudio) {
- audioTotalSize = this.newMessageAudio.size;
+ audioTotalSize = this.newMessageAudio.audio_blob?.size ?? 0;
fields["audio"] = {
audio_mode: this.newMessageAudio.audio_mode,
- audio_size: this.newMessageAudio.size,
+ audio_size: this.newMessageAudio.audio_blob?.size ?? 0,
audio_bytes: Utils.arrayBufferToBase64(await this.newMessageAudio.audio_blob.arrayBuffer()),
};
}
@@ -5101,6 +5113,135 @@ export default {
ToastUtils.error(this.$t("messages.failed_send_location_request"));
}
},
+ normalizeSidebandCommandKey(key) {
+ const raw = String(key ?? "").trim();
+ if (!raw) {
+ return null;
+ }
+ const known = {
+ plugin: "0x00",
+ telemetry_request: "0x01",
+ request: "0x01",
+ location_request: "0x01",
+ ping: "0x02",
+ echo: "0x03",
+ signal_report: "0x04",
+ };
+ if (Object.prototype.hasOwnProperty.call(known, raw.toLowerCase())) {
+ return known[raw.toLowerCase()];
+ }
+ if (/^\d+$/.test(raw)) {
+ const n = Number.parseInt(raw, 10);
+ if (Number.isInteger(n) && n >= 0 && n <= 255) {
+ return `0x${n.toString(16).padStart(2, "0")}`;
+ }
+ }
+ if (/^0x[0-9a-f]{1,2}$/i.test(raw)) {
+ return `0x${raw.slice(2).toLowerCase().padStart(2, "0")}`;
+ }
+ return null;
+ },
+ parseCommandOrRequestText(text) {
+ const trimmed = String(text || "").trim();
+ const nowTs = Math.floor(Date.now() / 1000);
+
+ if (!trimmed) {
+ // Keep old behavior for empty input: telemetry/location request.
+ return [{ "0x01": nowTs }];
+ }
+
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
+ const parsed = JSON.parse(trimmed);
+ const list = Array.isArray(parsed) ? parsed : [parsed];
+ const out = [];
+ for (const cmd of list) {
+ if (!cmd || typeof cmd !== "object" || Array.isArray(cmd)) {
+ continue;
+ }
+ const mapped = {};
+ for (const [k, v] of Object.entries(cmd)) {
+ const normalized = this.normalizeSidebandCommandKey(k);
+ if (!normalized) {
+ continue;
+ }
+ mapped[normalized] = v;
+ }
+ if (Object.keys(mapped).length > 0) {
+ out.push(mapped);
+ }
+ }
+ if (out.length === 0) {
+ throw new Error("No valid command keys found");
+ }
+ return out;
+ }
+
+ const firstSpace = trimmed.search(/\s/);
+ const keyToken = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace);
+ const arg = firstSpace === -1 ? "" : trimmed.slice(firstSpace + 1).trim();
+ const key = this.normalizeSidebandCommandKey(keyToken);
+ if (!key) {
+ return [{ "0x00": trimmed }];
+ }
+
+ if (key === "0x01") {
+ // request/location_request/telemetry_request
+ return [{ "0x01": arg ? Number.parseInt(arg, 10) || nowTs : nowTs }];
+ }
+ if (key === "0x02" || key === "0x04") {
+ return [{ [key]: arg ? arg.toLowerCase() !== "false" : true }];
+ }
+ if (key === "0x03") {
+ return [{ "0x03": arg || "ping" }];
+ }
+ if (key === "0x00") {
+ return [{ "0x00": arg || trimmed }];
+ }
+ return [{ [key]: arg || true }];
+ },
+ onComposerSendClick() {
+ if (this.pendingSendAsCommandOrRequest) {
+ void this.sendCommandOrRequest();
+ return;
+ }
+ void this.sendMessage();
+ },
+ toggleLocationActionMenu() {
+ this.showLocationActionMenu = !this.showLocationActionMenu;
+ },
+ closeLocationActionMenu() {
+ this.showLocationActionMenu = false;
+ },
+ async selectSendLocation() {
+ this.closeLocationActionMenu();
+ await this.shareLocation();
+ },
+ async selectRequestLocation() {
+ this.closeLocationActionMenu();
+ await this.requestLocation();
+ },
+ enableNextSendAsCommandOrRequest() {
+ this.pendingSendAsCommandOrRequest = true;
+ },
+ async sendCommandOrRequest() {
+ try {
+ if (!this.selectedPeer) return;
+ const commands = this.parseCommandOrRequestText(this.newMessageText);
+ await window.api.post(`/api/v1/lxmf-messages/send`, {
+ lxmf_message: {
+ destination_hash: this.selectedPeer.destination_hash,
+ content: "",
+ fields: { commands },
+ },
+ });
+ this.newMessageText = "";
+ this.pendingSendAsCommandOrRequest = false;
+ ToastUtils.success("Command or request sent");
+ } catch (e) {
+ console.log(e);
+ ToastUtils.error(`Failed to send command/request: ${e.message}`);
+ }
+ },
viewLocationOnMap(location) {
// navigate to map and center on location
this.$router.push({
@@ -5849,32 +5990,23 @@ export default {
break;
}
case "opus": {
- if (this.isMeshChatXAndroid() && window.MeshChatXAndroid?.startNativeWavAttachment) {
- if (
- typeof window.MeshChatXAndroid.isNativePcmAudioAvailable === "function" &&
- !window.MeshChatXAndroid.isNativePcmAudioAvailable()
- ) {
- DialogUtils.alert(this.buildAudioRecordingFailureMessage());
- break;
- }
+ if (this.isMeshChatXAndroid() && this.androidNativeWavAttachmentAllowed()) {
const res = window.MeshChatXAndroid.startNativeWavAttachment();
- if (res !== "ok") {
- DialogUtils.alert(this.buildAudioRecordingFailureMessage());
+ if (res === "ok") {
+ this.androidNativeOpusAttachment = true;
+ this.audioAttachmentMicrophoneRecorderCodec = "opus";
+ this.audioAttachmentMicrophoneRecorder = { _androidNative: true };
+ this.audioAttachmentRecordingStartedAt = Date.now();
+ this.isRecordingAudioAttachment = true;
+ this.audioAttachmentRecordingDuration = Utils.formatMinutesSeconds(0);
+ this.audioAttachmentRecordingTimer = setInterval(() => {
+ const recordingDurationMillis = Date.now() - this.audioAttachmentRecordingStartedAt;
+ const recordingDurationSeconds = recordingDurationMillis / 1000;
+ this.audioAttachmentRecordingDuration =
+ Utils.formatMinutesSeconds(recordingDurationSeconds);
+ }, 1000);
break;
}
- this.androidNativeOpusAttachment = true;
- this.audioAttachmentMicrophoneRecorderCodec = "opus";
- this.audioAttachmentMicrophoneRecorder = { _androidNative: true };
- this.audioAttachmentRecordingStartedAt = Date.now();
- this.isRecordingAudioAttachment = true;
- this.audioAttachmentRecordingDuration = Utils.formatMinutesSeconds(0);
- this.audioAttachmentRecordingTimer = setInterval(() => {
- const recordingDurationMillis = Date.now() - this.audioAttachmentRecordingStartedAt;
- const recordingDurationSeconds = recordingDurationMillis / 1000;
- this.audioAttachmentRecordingDuration =
- Utils.formatMinutesSeconds(recordingDurationSeconds);
- }, 1000);
- break;
}
this.audioAttachmentMicrophoneRecorderCodec = "opus";
this.audioAttachmentMicrophoneRecorder = new MicrophoneRecorder();
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index e851332e..82c0ef5a 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -270,7 +270,7 @@ import DialogUtils from "../../js/DialogUtils";
import DownloadUtils from "../../js/DownloadUtils";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
-import { lxmfConversationListPreview } from "../../js/lxmfReactions";
+import { lxmfConversationListPreview } from "../../js/lxmfConversationPreview";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index 89437d0e..3227563f 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -69,7 +69,6 @@
:icon-name="c.lxmf_user_icon ? c.lxmf_user_icon.icon_name : ''"
:icon-foreground-colour="c.lxmf_user_icon ? c.lxmf_user_icon.foreground_colour : ''"
:icon-background-colour="c.lxmf_user_icon ? c.lxmf_user_icon.background_colour : ''"
- :seed="c.destination_hash"
icon-class="size-9 shrink-0"
:icon-style="collapsedConversationIconStyle"
/>
diff --git a/meshchatx/src/frontend/components/messages/SendMessageButton.vue b/meshchatx/src/frontend/components/messages/SendMessageButton.vue
index dfb4718e..7ed2eaa7 100644
--- a/meshchatx/src/frontend/components/messages/SendMessageButton.vue
+++ b/meshchatx/src/frontend/components/messages/SendMessageButton.vue
@@ -4,11 +4,11 @@
<div class="relative inline-flex items-stretch rounded-xl shadow-xs">
<template v-if="compact">
<button
- :disabled="!canSendMessage"
+ :disabled="!canSendMessage && !canOpenSendMenu"
type="button"
class="inline-flex items-center justify-center rounded-xl p-2.5 min-h-[44px] min-w-[44px] text-white transition-colors focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 touch-manipulation select-none"
:class="[
- canSendMessage
+ canSendMessage || canOpenSendMenu
? 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus-visible:outline-blue-500'
: 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 cursor-not-allowed',
]"
@@ -73,11 +73,11 @@
</button>
<div class="relative self-stretch">
<button
- :disabled="!canSendMessage"
+ :disabled="!canSendMessage && !canOpenSendMenu"
type="button"
class="border-l relative inline-flex items-center justify-center rounded-r-xl px-2.5 h-full text-white transition-colors focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2"
:class="[
- canSendMessage
+ canSendMessage || canOpenSendMenu
? 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus-visible:outline-blue-500 border-blue-700 dark:border-blue-800'
: 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 border-gray-500 dark:border-zinc-600 cursor-not-allowed',
]"
@@ -136,6 +136,27 @@
>
Send to Propagation Node
</button>
+ <div
+ class="border-t border-gray-100 dark:border-zinc-800 text-[10px] font-semibold uppercase tracking-wider text-gray-500 dark:text-zinc-500 px-4 pt-2 pb-1"
+ >
+ {{ $t("messages.send_menu_more_label") }}
+ </div>
+ <button
+ type="button"
+ class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
+ :disabled="!canOpenSendMenu"
+ @click="emitCommandOrRequest"
+ >
+ {{ $t("messages.send_menu_telemetry_request") }}
+ </button>
+ <button
+ type="button"
+ class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
+ :disabled="!canSendMessage"
+ @click="emitPaperCompose"
+ >
+ {{ $t("messages.send_menu_paper_compose") }}
+ </button>
</div>
</div>
</Transition>
@@ -158,8 +179,12 @@ export default {
default:
"Resolving route to peer (finding path). This can take a while on first contact or after links change. Paths are remembered until they expire.",
},
+ canOpenSendMenu: {
+ type: Boolean,
+ default: false,
+ },
},
- emits: ["delivery-method-changed", "send"],
+ emits: ["delivery-method-changed", "send", "send-command-or-request", "send-paper-compose"],
data() {
return {
isShowingMenu: false,
@@ -186,7 +211,7 @@ export default {
}
},
onCompactPointerDown() {
- if (!this.compact || !this.canSendMessage) {
+ if (!this.compact || (!this.canSendMessage && !this.canOpenSendMenu)) {
return;
}
this.compactTapArmed = true;
@@ -230,6 +255,14 @@ export default {
this.$emit("delivery-method-changed", deliveryMethod);
this.hideMenu();
},
+ emitCommandOrRequest() {
+ this.$emit("send-command-or-request");
+ this.hideMenu();
+ },
+ emitPaperCompose() {
+ this.$emit("send-paper-compose");
+ this.hideMenu();
+ },
send() {
this.$emit("send");
},
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index bbd15a3f..fe816c78 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -290,6 +290,20 @@
<MaterialDesignIcon icon-name="open-in-new" class="size-5" />
<span>{{ $t("nomadnet.pop_out_browser") }}</span>
</DropDownMenuItem>
+ <DropDownMenuItem
+ v-if="showMicronRendererInMobileMenu"
+ @click="applyNomadMicronWasmEnabled(false)"
+ >
+ <MaterialDesignIcon icon-name="language-javascript" class="size-5" />
+ <span>{{ $t("nomadnet.renderer_menu_js") }}</span>
+ </DropDownMenuItem>
+ <DropDownMenuItem
+ v-if="showMicronRendererInMobileMenu"
+ @click="applyNomadMicronWasmEnabled(true)"
+ >
+ <MaterialDesignIcon icon-name="memory" class="size-5" />
+ <span>{{ $t("nomadnet.renderer_menu_wasm") }}</span>
+ </DropDownMenuItem>
</template>
</DropDownMenu>
@@ -556,7 +570,7 @@ import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import IconButton from "../IconButton.vue";
import DropDownMenu from "../DropDownMenu.vue";
import DropDownMenuItem from "../DropDownMenuItem.vue";
-import GlobalState from "../../js/GlobalState";
+import GlobalState, { mergeGlobalConfig } from "../../js/GlobalState";
import { patchServerConfig } from "../../js/settings/settingsConfigService";
import {
preloadNomadMicronWasm,
@@ -733,6 +747,13 @@ export default {
}
return null;
},
+ showMicronRendererInMobileMenu() {
+ if (!this.wasmBundled || !this.selectedNode || !this.nodePagePath || this.isShowingNodePageSource) {
+ return false;
+ }
+ const [p] = this.nodePagePath.split("`");
+ return (p || "").toLowerCase().endsWith(".mu");
+ },
blockedDestinations() {
return GlobalState.blockedDestinations;
},
@@ -956,16 +977,20 @@ export default {
if (!isMicronWasmBundled()) {
return;
}
- const newValue = !GlobalState.config.nomad_micron_wasm_enabled;
+ await this.applyNomadMicronWasmEnabled(!GlobalState.config.nomad_micron_wasm_enabled);
+ },
+ async applyNomadMicronWasmEnabled(useWasm) {
+ if (!isMicronWasmBundled()) {
+ return;
+ }
+ const newValue = Boolean(useWasm);
+ if (Boolean(GlobalState.config?.nomad_micron_wasm_enabled) === newValue) {
+ return;
+ }
try {
- await patchServerConfig({ nomad_micron_wasm_enabled: newValue }, window.api);
- // GlobalState.config is reactive and updated by the server response or by the patch call
- // but we might need to force a re-render or reload the page if it doesn't happen automatically.
- // In this case, nomadMicronWasmFeatureEffective depends on config, and renderedNodePageHtml
- // depends on nomad_micron_wasm_use which depends on nomadMicronWasmFeatureEffective.
- // So it should just work.
+ const next = await patchServerConfig({ nomad_micron_wasm_enabled: newValue }, window.api);
+ mergeGlobalConfig(next);
if (this.nodePageContent && this.nodePagePath && this.nodePagePath.toLowerCase().endsWith(".mu")) {
- // Force re-render of current page if it's a micron page
const content = this.nodePageContent;
this.nodePageContent = null;
this.$nextTick(() => {
@@ -973,8 +998,8 @@ export default {
});
}
} catch (e) {
- console.error("Failed to toggle Micron WASM", e);
- ToastUtils.showError("Failed to update setting");
+ console.error("Failed to update Micron WASM preference", e);
+ ToastUtils.error(this.$t("nomadnet.renderer_setting_failed"));
}
},
scheduleProcessPartials() {
diff --git a/meshchatx/src/frontend/js/lxmfConversationPreview.js b/meshchatx/src/frontend/js/lxmfConversationPreview.js
new file mode 100644
index 00000000..360d0748
--- /dev/null
+++ b/meshchatx/src/frontend/js/lxmfConversationPreview.js
@@ -0,0 +1,106 @@
+/**
+ * One-line preview text for the conversation list / sidebar (LXMF latest row).
+ * Handles plain content, Columba reactions, location/telemetry fields, and
+ * Sideband location-request commands. Pairs with the Python helper
+ * lxmf_sidebar_preview_for_conversation_latest_row.
+ */
+
+import { reactionEmojiFromLxmfMessageFields } from "./lxmfReactions.js";
+
+function sidebarActorName(msg, { myLxmfAddressHash, peerDisplayName, t }) {
+ const incoming = Boolean(msg?.is_incoming);
+ const src = String(msg?.source_hash || "").toLowerCase();
+ const me = String(myLxmfAddressHash || "").toLowerCase();
+ const senderIsYou = !incoming && me && src === me;
+ if (incoming) {
+ return peerDisplayName || "Anonymous Peer";
+ }
+ if (senderIsYou) {
+ return typeof t === "function" ? t("messages.reaction_you") : "You";
+ }
+ return peerDisplayName || "Anonymous Peer";
+}
+
+function fieldsHaveSidebandLocationRequest(fields) {
+ if (!fields?.commands || !Array.isArray(fields.commands)) {
+ return false;
+ }
+ return fields.commands.some(
+ (c) => c && (c["0x01"] !== undefined || c["1"] !== undefined || c["0x1"] !== undefined)
+ );
+}
+
+function fieldsHaveTelemetryLocation(fields) {
+ const loc = fields?.telemetry?.location;
+ return loc != null && typeof loc === "object";
+}
+
+function isOutboundFromSelf(msg, myLxmfAddressHash) {
+ if (msg?.is_incoming) {
+ return false;
+ }
+ const src = String(msg?.source_hash || "").toLowerCase();
+ const me = String(myLxmfAddressHash || "").toLowerCase();
+ return Boolean(me && src === me);
+}
+
+/**
+ * @param {object} msg
+ * @param {{ myLxmfAddressHash: string, peerDisplayName: string, t?: (k: string, v?: object) => string }} ctx
+ * @returns {string}
+ */
+export function lxmfConversationListPreview(msg, { myLxmfAddressHash, peerDisplayName, t }) {
+ const raw = msg?.content;
+ const content = typeof raw === "string" ? raw.trim() : "";
+ if (content) {
+ return raw;
+ }
+
+ const fields = msg?.fields;
+ const name = sidebarActorName(msg, { myLxmfAddressHash, peerDisplayName, t });
+
+ const emoji =
+ (msg?.is_reaction && typeof msg?.reaction_emoji === "string" && msg.reaction_emoji) ||
+ reactionEmojiFromLxmfMessageFields(fields);
+ if (emoji) {
+ if (typeof t === "function") {
+ return t("messages.conversation_reaction_preview", { name, emoji });
+ }
+ return `${name} reacted ${emoji}`;
+ }
+
+ if (fieldsHaveTelemetryLocation(fields)) {
+ const fromSelf = isOutboundFromSelf(msg, myLxmfAddressHash);
+ if (typeof t === "function") {
+ return fromSelf
+ ? t("messages.conversation_location_share_you")
+ : t("messages.conversation_location_share_other", { name });
+ }
+ return fromSelf ? "You shared your location" : `${name} shared their location`;
+ }
+
+ if (fields?.telemetry_stream?.length) {
+ if (typeof t === "function") {
+ return t("messages.conversation_telemetry_stream_preview", { name });
+ }
+ return `${name} sent a telemetry stream`;
+ }
+
+ if (fields?.telemetry && typeof fields.telemetry === "object" && Object.keys(fields.telemetry).length > 0) {
+ if (typeof t === "function") {
+ return t("messages.conversation_telemetry_preview", { name });
+ }
+ return `${name} sent telemetry`;
+ }
+
+ if (fieldsHaveSidebandLocationRequest(fields)) {
+ if (typeof t === "function") {
+ return msg?.is_incoming
+ ? t("messages.conversation_location_request_in_preview", { name })
+ : t("messages.conversation_location_request_out_preview", { name });
+ }
+ return msg?.is_incoming ? `${name} requested your location` : `${name} sent a location request`;
+ }
+
+ return raw ?? "";
+}
diff --git a/meshchatx/src/frontend/js/lxmfReactions.js b/meshchatx/src/frontend/js/lxmfReactions.js
index 75267968..235cd1a1 100644
--- a/meshchatx/src/frontend/js/lxmfReactions.js
+++ b/meshchatx/src/frontend/js/lxmfReactions.js
@@ -11,7 +11,8 @@ export const COLUMBA_REACTION_EMOJIS = [
"\u{1F621}",
];
-function reactionEmojiFromLxmfMessageFields(fields) {
+/** Emoji from LXMF fields (Columba app_extensions), for conversation preview and UI. */
+export function reactionEmojiFromLxmfMessageFields(fields) {
if (!fields || typeof fields !== "object") {
return "";
}
@@ -22,43 +23,6 @@ function reactionEmojiFromLxmfMessageFields(fields) {
return "";
}
-/**
- * One-line preview for conversation list / sidebar (plain text or i18n via `t`).
- */
-export function lxmfConversationListPreview(msg, { myLxmfAddressHash, peerDisplayName, t }) {
- const raw = msg?.content;
- const content = typeof raw === "string" ? raw.trim() : "";
- if (content) {
- return raw;
- }
-
- const emoji =
- (msg?.is_reaction && typeof msg?.reaction_emoji === "string" && msg.reaction_emoji) ||
- reactionEmojiFromLxmfMessageFields(msg?.fields);
- if (!emoji) {
- return raw ?? "";
- }
-
- const incoming = Boolean(msg?.is_incoming);
- const src = String(msg?.source_hash || "").toLowerCase();
- const me = String(myLxmfAddressHash || "").toLowerCase();
- const reactorIsYou = !incoming && me && src === me;
-
- let name;
- if (incoming) {
- name = peerDisplayName || "Anonymous Peer";
- } else if (reactorIsYou) {
- name = typeof t === "function" ? t("messages.reaction_you") : "You";
- } else {
- name = peerDisplayName || "Anonymous Peer";
- }
-
- if (typeof t === "function") {
- return t("messages.conversation_reaction_preview", { name, emoji });
- }
- return `${name} reacted ${emoji}`;
-}
-
export function mergeLxmfReactionRowsIntoMessages(messages) {
if (!Array.isArray(messages)) {
return [];
diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js
index b507280c..e2d82a37 100644
--- a/tests/frontend/ConversationViewer.test.js
+++ b/tests/frontend/ConversationViewer.test.js
@@ -2008,4 +2008,50 @@ describe("ConversationViewer.vue", () => {
});
});
});
+
+ describe("android native wav attachment gate", () => {
+ afterEach(() => {
+ delete window.MeshChatXAndroid;
+ });
+
+ it("prefers isNativeWavAttachmentAvailable when present", () => {
+ window.MeshChatXAndroid = {
+ getPlatform: () => "android",
+ startNativeWavAttachment: vi.fn(),
+ isNativeWavAttachmentAvailable: () => true,
+ isNativePcmAudioAvailable: () => false,
+ };
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.androidNativeWavAttachmentAllowed()).toBe(true);
+ });
+
+ it("falls back to isNativePcmAudioAvailable when wav-specific method is absent", () => {
+ window.MeshChatXAndroid = {
+ getPlatform: () => "android",
+ startNativeWavAttachment: vi.fn(),
+ isNativePcmAudioAvailable: () => true,
+ };
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.androidNativeWavAttachmentAllowed()).toBe(true);
+ });
+
+ it("is false when isNativeWavAttachmentAvailable returns false", () => {
+ window.MeshChatXAndroid = {
+ getPlatform: () => "android",
+ startNativeWavAttachment: vi.fn(),
+ isNativeWavAttachmentAvailable: () => false,
+ isNativePcmAudioAvailable: () => true,
+ };
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.androidNativeWavAttachmentAllowed()).toBe(false);
+ });
+
+ it("is false without startNativeWavAttachment", () => {
+ window.MeshChatXAndroid = {
+ getPlatform: () => "android",
+ };
+ const wrapper = mountConversationViewer();
+ expect(wrapper.vm.androidNativeWavAttachmentAllowed()).toBe(false);
+ });
+ });
});
diff --git a/tests/frontend/ConversationViewerButtons.test.js b/tests/frontend/ConversationViewerButtons.test.js
index f88e82a7..50594b08 100644
--- a/tests/frontend/ConversationViewerButtons.test.js
+++ b/tests/frontend/ConversationViewerButtons.test.js
@@ -304,6 +304,13 @@ describe("ConversationViewer.vue button interactions", () => {
clickSpy.mockRestore();
});
+ it("file input does not restrict accepted mime types", async () => {
+ const wrapper = mountViewer();
+ await wrapper.vm.$nextTick();
+ const fileInput = wrapper.find('input[type="file"]');
+ expect(fileInput.attributes("accept")).toBeUndefined();
+ });
+
it("onFileInputChange appends selected files to newMessageFiles", () => {
const wrapper = mountViewer();
const f = new File(["x"], "attach.txt", { type: "text/plain" });
@@ -320,28 +327,11 @@ describe("ConversationViewer.vue button interactions", () => {
expect(wrapper.vm.newMessageFiles).toEqual([f2]);
});
- it("paste toolbar button reads clipboard text into the message field", async () => {
- const readText = vi.fn(() => Promise.resolve("from-clipboard"));
- vi.stubGlobal("navigator", {
- ...navigator,
- clipboard: { readText },
- });
- const prevSc = window.isSecureContext;
- Object.defineProperty(window, "isSecureContext", { configurable: true, value: true });
- try {
- const wrapper = mountViewer();
- await wrapper.vm.$nextTick();
- const ta = wrapper.find("#message-input").element;
- ta.selectionStart = 0;
- ta.selectionEnd = 0;
- wrapper.vm.newMessageText = "";
-
- const actionButtons = wrapper.findAll(".attachment-action-button");
- await actionButtons[1].trigger("click");
- await vi.waitFor(() => expect(wrapper.vm.newMessageText).toBe("from-clipboard"));
- } finally {
- Object.defineProperty(window, "isSecureContext", { configurable: true, value: prevSc });
- }
+ it("does not render a dedicated paste toolbar button", async () => {
+ const wrapper = mountViewer();
+ await wrapper.vm.$nextTick();
+ const pasteIcon = wrapper.find('[icon-name="content-paste"]');
+ expect(pasteIcon.exists()).toBe(false);
});
it("applyComposeTranslation posts translate and replaces text", async () => {
@@ -401,6 +391,96 @@ describe("ConversationViewer.vue button interactions", () => {
);
});
+ it("location action menu toggles and closes", () => {
+ const wrapper = mountViewer();
+ expect(wrapper.vm.showLocationActionMenu).toBe(false);
+ wrapper.vm.toggleLocationActionMenu();
+ expect(wrapper.vm.showLocationActionMenu).toBe(true);
+ wrapper.vm.closeLocationActionMenu();
+ expect(wrapper.vm.showLocationActionMenu).toBe(false);
+ });
+
+ it("selectSendLocation closes menu and calls shareLocation", async () => {
+ const wrapper = mountViewer();
+ wrapper.vm.showLocationActionMenu = true;
+ const shareSpy = vi.spyOn(wrapper.vm, "shareLocation").mockResolvedValue(undefined);
+ await wrapper.vm.selectSendLocation();
+ expect(shareSpy).toHaveBeenCalled();
+ expect(wrapper.vm.showLocationActionMenu).toBe(false);
+ shareSpy.mockRestore();
+ });
+
+ it("selectRequestLocation closes menu and calls requestLocation", async () => {
+ const wrapper = mountViewer();
+ wrapper.vm.showLocationActionMenu = true;
+ const reqSpy = vi.spyOn(wrapper.vm, "requestLocation").mockResolvedValue(undefined);
+ await wrapper.vm.selectRequestLocation();
+ expect(reqSpy).toHaveBeenCalled();
+ expect(wrapper.vm.showLocationActionMenu).toBe(false);
+ reqSpy.mockRestore();
+ });
+
+ it("sendCommandOrRequest sends typed Sideband command", async () => {
+ const wrapper = mountViewer();
+ wrapper.vm.newMessageText = "ping";
+ await wrapper.vm.sendCommandOrRequest();
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ "/api/v1/lxmf-messages/send",
+ expect.objectContaining({
+ lxmf_message: expect.objectContaining({
+ destination_hash: "a".repeat(32),
+ fields: {
+ commands: [{ "0x02": true }],
+ },
+ }),
+ })
+ );
+ });
+
+ it("sendCommandOrRequest accepts JSON command input", async () => {
+ const wrapper = mountViewer();
+ wrapper.vm.newMessageText = '{"0x03":"hello"}';
+ await wrapper.vm.sendCommandOrRequest();
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ "/api/v1/lxmf-messages/send",
+ expect.objectContaining({
+ lxmf_message: expect.objectContaining({
+ fields: {
+ commands: [{ "0x03": "hello" }],
+ },
+ }),
+ })
+ );
+ });
+
+ it("enableNextSendAsCommandOrRequest only arms mode until send button is pressed", async () => {
+ const wrapper = mountViewer();
+ expect(wrapper.vm.pendingSendAsCommandOrRequest).toBe(false);
+ wrapper.vm.enableNextSendAsCommandOrRequest();
+ expect(wrapper.vm.pendingSendAsCommandOrRequest).toBe(true);
+ wrapper.vm.newMessageText = "ping";
+ await wrapper.vm.onComposerSendClick();
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ "/api/v1/lxmf-messages/send",
+ expect.objectContaining({
+ lxmf_message: expect.objectContaining({
+ fields: {
+ commands: [{ "0x02": true }],
+ },
+ }),
+ })
+ );
+ expect(wrapper.vm.pendingSendAsCommandOrRequest).toBe(false);
+ });
+
+ it("onComposerSendClick falls back to normal message send when not armed", async () => {
+ const wrapper = mountViewer();
+ const sendSpy = vi.spyOn(wrapper.vm, "sendMessage").mockResolvedValue(undefined);
+ await wrapper.vm.onComposerSendClick();
+ expect(sendSpy).toHaveBeenCalled();
+ sendSpy.mockRestore();
+ });
+
it("shareLocation with manual coordinates sets telemetry and calls sendMessage", async () => {
const wrapper = mountViewer({
config: {
diff --git a/tests/frontend/MessagesPage.test.js b/tests/frontend/MessagesPage.test.js
index e66c74b7..e3948a21 100644
--- a/tests/frontend/MessagesPage.test.js
+++ b/tests/frontend/MessagesPage.test.js
@@ -188,6 +188,43 @@ describe("MessagesPage.vue", () => {
expect(convCalls).toHaveLength(0);
});
+ it("sets sidebar preview i18n key for outbound telemetry location on lxmf_message_created", async () => {
+ const destHash = "a".repeat(32);
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.conversations = [
+ {
+ destination_hash: destHash,
+ display_name: "Test Peer",
+ latest_message_preview: "old",
+ updated_at: "2025-01-01T00:00:00Z",
+ },
+ ];
+
+ axiosMock.get.mockClear();
+
+ await wrapper.vm.onWebsocketMessage({
+ data: JSON.stringify({
+ type: "lxmf_message_created",
+ lxmf_message: {
+ hash: "loc1",
+ source_hash: "my-hash",
+ destination_hash: destHash,
+ is_incoming: false,
+ content: "",
+ title: "",
+ timestamp: 1700000000,
+ fields: { telemetry: { location: { latitude: 1, longitude: 2 } } },
+ },
+ }),
+ });
+
+ expect(wrapper.vm.conversations[0].latest_message_preview).toBe("messages.conversation_location_share_you");
+ const convCalls = axiosMock.get.mock.calls.filter((c) => c[0] === "/api/v1/lxmf/conversations");
+ expect(convCalls).toHaveLength(0);
+ });
+
it("resolves display name for new conversation only", async () => {
const destHash = "d".repeat(32);
const wrapper = mountMessagesPage();
diff --git a/tests/frontend/NomadNetworkPage.test.js b/tests/frontend/NomadNetworkPage.test.js
index 6e80a24d..93407a9d 100644
--- a/tests/frontend/NomadNetworkPage.test.js
+++ b/tests/frontend/NomadNetworkPage.test.js
@@ -132,6 +132,56 @@ describe("NomadNetworkPage.vue", () => {
}
});
+ describe("showMicronRendererInMobileMenu", () => {
+ it("is true on .mu page when wasm bundled and not in source view", async () => {
+ const dest = "a".repeat(32);
+ const wrapper = mountNomadNetworkPage();
+ await wrapper.setData({
+ wasmBundled: true,
+ selectedNode: { destination_hash: dest, display_name: "N" },
+ nodePagePath: `${dest}:/page/index.mu`,
+ isShowingNodePageSource: false,
+ });
+ expect(wrapper.vm.showMicronRendererInMobileMenu).toBe(true);
+ });
+
+ it("is false without selectedNode", async () => {
+ const dest = "c".repeat(32);
+ const wrapper = mountNomadNetworkPage();
+ await wrapper.setData({
+ wasmBundled: true,
+ selectedNode: null,
+ nodePagePath: `${dest}:/page/index.mu`,
+ isShowingNodePageSource: false,
+ });
+ expect(wrapper.vm.showMicronRendererInMobileMenu).toBe(false);
+ });
+
+ it("is false in source view", async () => {
+ const dest = "b".repeat(32);
+ const wrapper = mountNomadNetworkPage();
+ await wrapper.setData({
+ wasmBundled: true,
+ selectedNode: { destination_hash: dest, display_name: "N" },
+ nodePagePath: `${dest}:/page/index.mu`,
+ isShowingNodePageSource: true,
+ });
+ expect(wrapper.vm.showMicronRendererInMobileMenu).toBe(false);
+ });
+
+ it("is false when wasm is not bundled", async () => {
+ const dest = "d".repeat(32);
+ const wrapper = mountNomadNetworkPage();
+ await wrapper.setData({
+ wasmBundled: false,
+ selectedNode: { destination_hash: dest, display_name: "N" },
+ nodePagePath: `${dest}:/page/index.mu`,
+ isShowingNodePageSource: false,
+ });
+ expect(wrapper.vm.showMicronRendererInMobileMenu).toBe(false);
+ });
+ });
+
describe("partials", () => {
it("clearPartials resets partial state and timers", () => {
const wrapper = mountNomadNetworkPage();
diff --git a/tests/frontend/UIComponents.test.js b/tests/frontend/UIComponents.test.js
index 96242adf..990bdf0f 100644
--- a/tests/frontend/UIComponents.test.js
+++ b/tests/frontend/UIComponents.test.js
@@ -68,8 +68,11 @@ describe("DropDownMenuItem Component", () => {
});
describe("SendMessageButton Component", () => {
+ const sendBtnGlobal = { mocks: { $t: (k) => k } };
+
it("renders send button with correct text when enabled", () => {
const wrapper = mount(SendMessageButton, {
+ global: sendBtnGlobal,
props: {
canSendMessage: true,
isSendingMessage: false,
@@ -81,6 +84,7 @@ describe("SendMessageButton Component", () => {
it("shows sending state when isSendingMessage is true", () => {
const wrapper = mount(SendMessageButton, {
+ global: sendBtnGlobal,
props: {
canSendMessage: true,
isSendingMessage: true,
@@ -93,6 +97,7 @@ describe("SendMessageButton Component", () => {
it("disables button when canSendMessage is false", () => {
const wrapper = mount(SendMessageButton, {
+ global: sendBtnGlobal,
props: {
canSendMessage: false,
isSendingMessage: false,
@@ -105,6 +110,7 @@ describe("SendMessageButton Component", () => {
it("shows delivery method in button text", () => {
const wrapper = mount(SendMessageButton, {
+ global: sendBtnGlobal,
props: {
canSendMessage: true,
isSendingMessage: false,
@@ -116,6 +122,7 @@ describe("SendMessageButton Component", () => {
it("emits send event when send button is clicked", async () => {
const wrapper = mount(SendMessageButton, {
+ global: sendBtnGlobal,
props: {
canSendMessage: true,
isSendingMessage: false,
@@ -129,6 +136,7 @@ describe("SendMessageButton Component", () => {
it("opens dropdown menu when dropdown button is clicked", async () => {
const wrapper = mount(SendMessageButton, {
+ global: sendBtnGlobal,
props: {
canSendMessage: true,
isSendingMessage: false,
@@ -142,6 +150,7 @@ describe("SendMessageButton Component", () => {
it("emits delivery-method-changed when delivery method is selected", async () => {
const wrapper = mount(SendMessageButton, {
+ global: sendBtnGlobal,
props: {
canSendMessage: true,
isSendingMessage: false,
@@ -157,6 +166,7 @@ describe("SendMessageButton Component", () => {
it("closes menu after selecting delivery method", async () => {
const wrapper = mount(SendMessageButton, {
+ global: sendBtnGlobal,
props: {
canSendMessage: true,
isSendingMessage: false,
@@ -168,6 +178,39 @@ describe("SendMessageButton Component", () => {
wrapper.vm.setDeliveryMethod("direct");
expect(wrapper.vm.isShowingMenu).toBe(false);
});
+
+ it("emits send-command-or-request from menu", async () => {
+ const wrapper = mount(SendMessageButton, {
+ global: sendBtnGlobal,
+ props: {
+ canSendMessage: false,
+ canOpenSendMenu: true,
+ isSendingMessage: false,
+ deliveryMethod: null,
+ },
+ });
+ wrapper.vm.showMenu();
+ await wrapper.vm.$nextTick();
+ wrapper.vm.emitCommandOrRequest();
+ expect(wrapper.emitted("send-command-or-request")).toBeTruthy();
+ expect(wrapper.vm.isShowingMenu).toBe(false);
+ });
+
+ it("emits send-paper-compose from menu", async () => {
+ const wrapper = mount(SendMessageButton, {
+ global: sendBtnGlobal,
+ props: {
+ canSendMessage: true,
+ canOpenSendMenu: true,
+ isSendingMessage: false,
+ deliveryMethod: null,
+ },
+ });
+ wrapper.vm.showMenu();
+ await wrapper.vm.$nextTick();
+ wrapper.vm.emitPaperCompose();
+ expect(wrapper.emitted("send-paper-compose")).toBeTruthy();
+ });
});
describe("Toggle Component", () => {
@@ -624,8 +667,10 @@ describe("Button Interactions and Accessibility", () => {
it("SendMessageButton respects disabled state for keyboard", () => {
const wrapper = mount(SendMessageButton, {
+ global: { mocks: { $t: (k) => k } },
props: {
canSendMessage: false,
+ canOpenSendMenu: false,
isSendingMessage: false,
deliveryMethod: null,
},
diff --git a/tests/frontend/lxmfConversationPreview.test.js b/tests/frontend/lxmfConversationPreview.test.js
new file mode 100644
index 00000000..eae2133a
--- /dev/null
+++ b/tests/frontend/lxmfConversationPreview.test.js
@@ -0,0 +1,99 @@
+import { describe, expect, it } from "vitest";
+import { lxmfConversationListPreview } from "../../meshchatx/src/frontend/js/lxmfConversationPreview";
+
+describe("lxmfConversationListPreview", () => {
+ const me = "m".repeat(32);
+ const peer = "p".repeat(32);
+
+ it("uses reaction_emoji for outbound reaction from self", () => {
+ const s = lxmfConversationListPreview(
+ {
+ content: " ",
+ is_incoming: false,
+ is_reaction: true,
+ reaction_emoji: "\u{1F44D}",
+ source_hash: me,
+ },
+ { myLxmfAddressHash: me, peerDisplayName: "Pat" }
+ );
+ expect(s).toBe("You reacted \u{1F44D}");
+ });
+
+ it("uses peer name for incoming reaction", () => {
+ const s = lxmfConversationListPreview(
+ {
+ content: "",
+ is_incoming: true,
+ is_reaction: true,
+ reaction_emoji: "\u2764\uFE0F",
+ source_hash: peer,
+ },
+ { myLxmfAddressHash: me, peerDisplayName: "Alex" }
+ );
+ expect(s).toBe("Alex reacted \u2764\uFE0F");
+ });
+
+ it("reads emoji from fields.app_extensions when body fields are used", () => {
+ const s = lxmfConversationListPreview(
+ {
+ content: "",
+ is_incoming: true,
+ fields: { app_extensions: { reaction_to: "deadbeef", emoji: "\u{1F602}" } },
+ },
+ { myLxmfAddressHash: me, peerDisplayName: "Sam" }
+ );
+ expect(s).toBe("Sam reacted \u{1F602}");
+ });
+
+ it("shows location share preview for telemetry with coordinates", () => {
+ const s = lxmfConversationListPreview(
+ {
+ content: "",
+ is_incoming: true,
+ source_hash: peer,
+ fields: { telemetry: { location: { latitude: 1, longitude: 2 } } },
+ },
+ { myLxmfAddressHash: me, peerDisplayName: "Alex" }
+ );
+ expect(s).toBe("Alex shared their location");
+ });
+
+ it("shows outbound location share preview as You", () => {
+ const s = lxmfConversationListPreview(
+ {
+ content: "",
+ is_incoming: false,
+ source_hash: me,
+ fields: { telemetry: { location: { latitude: 1, longitude: 2 } } },
+ },
+ { myLxmfAddressHash: me, peerDisplayName: "Pat" }
+ );
+ expect(s).toBe("You shared your location");
+ });
+
+ it("shows outbound location request preview as You", () => {
+ const s = lxmfConversationListPreview(
+ {
+ content: "",
+ is_incoming: false,
+ source_hash: me,
+ fields: { commands: [{ "0x01": 1700000000 }] },
+ },
+ { myLxmfAddressHash: me, peerDisplayName: "Pat" }
+ );
+ expect(s).toBe("You sent a location request");
+ });
+
+ it("shows incoming location request preview with peer name", () => {
+ const s = lxmfConversationListPreview(
+ {
+ content: "",
+ is_incoming: true,
+ source_hash: peer,
+ fields: { commands: [{ "0x01": 1700000000 }] },
+ },
+ { myLxmfAddressHash: me, peerDisplayName: "Riley" }
+ );
+ expect(s).toBe("Riley requested your location");
+ });
+});
diff --git a/tests/frontend/lxmfReactions.test.js b/tests/frontend/lxmfReactions.test.js
index 7cfdc0aa..d6a74c1c 100644
--- a/tests/frontend/lxmfReactions.test.js
+++ b/tests/frontend/lxmfReactions.test.js
@@ -1,53 +1,5 @@
import { describe, expect, it } from "vitest";
-import {
- lxmfConversationListPreview,
- mergeLxmfReactionRowsIntoMessages,
-} from "../../meshchatx/src/frontend/js/lxmfReactions";
-
-describe("lxmfConversationListPreview", () => {
- const me = "m".repeat(32);
- const peer = "p".repeat(32);
-
- it("uses reaction_emoji for outbound reaction from self", () => {
- const s = lxmfConversationListPreview(
- {
- content: " ",
- is_incoming: false,
- is_reaction: true,
- reaction_emoji: "\u{1F44D}",
- source_hash: me,
- },
- { myLxmfAddressHash: me, peerDisplayName: "Pat" }
- );
- expect(s).toBe("You reacted \u{1F44D}");
- });
-
- it("uses peer name for incoming reaction", () => {
- const s = lxmfConversationListPreview(
- {
- content: "",
- is_incoming: true,
- is_reaction: true,
- reaction_emoji: "\u2764\uFE0F",
- source_hash: peer,
- },
- { myLxmfAddressHash: me, peerDisplayName: "Alex" }
- );
- expect(s).toBe("Alex reacted \u2764\uFE0F");
- });
-
- it("reads emoji from fields.app_extensions when body fields are used", () => {
- const s = lxmfConversationListPreview(
- {
- content: "",
- is_incoming: true,
- fields: { app_extensions: { reaction_to: "deadbeef", emoji: "\u{1F602}" } },
- },
- { myLxmfAddressHash: me, peerDisplayName: "Sam" }
- );
- expect(s).toBe("Sam reacted \u{1F602}");
- });
-});
+import { mergeLxmfReactionRowsIntoMessages } from "../../meshchatx/src/frontend/js/lxmfReactions";
describe("mergeLxmfReactionRowsIntoMessages", () => {
it("returns an empty array when input is not an array", () => {
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────